package com.nulldev.util.web.HttpServer3.pages.v3;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.channels.WritableByteChannel;
import java.security.cert.CertificateEncodingException;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;

import javax.net.ssl.SSLSocket;

import com.nulldev.util.VariableAPI.ArrayUtils;
import com.nulldev.util.VariableAPI.StringsUtil;
import com.nulldev.util.data.Variables;
import com.nulldev.util.data.Arrays.maps.FastMaps;
import com.nulldev.util.data.Charsets.CharsetUtil;
import com.nulldev.util.io.IOUtils;
import com.nulldev.util.io.extensions.copyable.Copyable;
import com.nulldev.util.io.extensions.copyable.nio.NIOCopyable;
import com.nulldev.util.osAPI.OS;
import com.nulldev.util.web.HttpServer3.Page;
import com.nulldev.util.web.HttpServer3.Response;
import com.nulldev.util.web.HttpServer3.legacyPages.v1.RequestType;
import com.nulldev.util.web.HttpServer3.util.DummySocket;
import com.nulldev.util.web.HttpServer3.util.HttpServerUtil;
import com.nulldev.util.web.URLUtils.Entry;
import com.nulldev.util.web.URLUtils.URLQueryParser;
import com.nulldev.util.web.URLUtils.nStreamHandlerFactory;

public abstract class HttpPage3 implements Page {

	public static final InputStream BLANK_IO_STREAM = nStreamHandlerFactory.BLANK;
	public static final Copyable NOOP_COPYABLE = new Copyable() {

		@Override
		public void writeTo(final OutputStream os) throws IOException {
		}
	};
	public static final NIOCopyable NOOP_NIO_COPYABLE = new NIOCopyable() {

		@Override
		public void writeTo(final WritableByteChannel os) throws IOException {
		}
	};

	/**
	 * Progress tracker:
	 * 
	 * Ongoing: <br>
	 * - Added enhanced security checks.
	 * 
	 * Complete: <br>
	 * - Add Request::urlAsQueryLists and Request::urlAsQueryList.
	 * 
	 * Planned: <br>
	 * - Add nCaptcha controls. <br>
	 * - Add caching controls.
	 */

	private final String id;
	private final AtomicReference<CachingMethod> caching = new AtomicReference<HttpPage3.CachingMethod>(CachingMethod.BLANK);
	private final AtomicBoolean PAGE_EXCLUDE_FROM_CAPTCHA;
	private final AtomicBoolean USE_JSON_CAPTCHA_RESPONSE = new AtomicBoolean();
	private final AtomicReference<EnhancedSecurityChecks[]> checks = new AtomicReference<EnhancedSecurityChecks[]>();

	public HttpPage3(String id) {
		this(id, false);
	}

	public HttpPage3(final String id, final boolean captchaExclusion) {
		Variables.requireNonNull(id, "id");
		if (!id.startsWith("/")) {
			this.id = "/" + id;
		} else
			this.id = id.replaceAll(" ", "%20");
		this.PAGE_EXCLUDE_FROM_CAPTCHA = new AtomicBoolean(captchaExclusion);
	}

	/**
	 * Returns the page ID.
	 * 
	 * @returns String
	 */
	public String getID() {
		return this.id;
	}

	public static enum CachingMethod {
		BLANK(""),
		NO_CACHE("no-cache"),
		EXTREMLY_SHORT_CACHE("max-age=15"),
		VERY_SHORT_CACHE("max-age=60"),
		SHORT_CACHE("max-age=180"),
		LONG_CACHE("max-age=3600"),
		VERY_LONG_CACHE("max-age=86400"),
		EXTREMLY_LONG_CACHE("max-age=31536000"),

		;

		private final String data;

		private CachingMethod(final String data) {
			this.data = data;
		}

		public String getStringValue() {
			return this.data;
		}
	}

	public static enum EnhancedSecurityChecks {
		/**
		 * Returns errors if any data is sent.
		 */
		DISALLOW_POST_DATA("post_data_disable"),
		/**
		 * Performs check for any url's that are inserted.
		 */
		POST_DATA_NO_URLS("post_no_url"),
		/**
		 * Only allows the following methods: GET, POST, HEAD, OPTIONS <br>
		 * OPTIONS was added due to CORS. I hate web """""security"""""
		 */
		ONLY_ALLOW_BASIC_METHODS("allow_basic_methods"),
		/**
		 * Requires the Authorization header to be present and set.
		 */
		REQUIRE_AUTHORIZATION_HEADER("require_authorization"),
		/**
		 * Returns errors if any headers are present which suggest a proxy was used.
		 */
		DISALLOW_PROXIES("no_proxies"),

		;

		public final String id;

		private EnhancedSecurityChecks(final String id) {
			this.id = id;
		}
	}

	public boolean hasChecks() {
		return this.checks.get() != null && this.checks.get().length > 0;
	}

	/**
	 * Returns all present security checks, if there are no checks present, a blank
	 * array is returned.
	 * 
	 * @returns EnhancedSecurityChecks[]
	 */
	public EnhancedSecurityChecks[] getChecks() {
		if (this.checks.get() == null)
			return new EnhancedSecurityChecks[0];
		return this.checks.get();
	}

	public HttpPage3 setChecks(final EnhancedSecurityChecks... checks) {
		this.checks.set(checks);
		return this;
	}

	/**
	 * Returns the current <code>CachingMethod</code>.
	 * 
	 * @returns CachingMethod
	 */
	public CachingMethod getCachingMethod() {
		return this.caching.get();
	}

	/**
	 * Sets the <code>CachingMethod</code> for the page if the <code>method</code>
	 * is not null.
	 * 
	 * @param method - CachingMethod
	 * @returns HttpPage3 (same instance)
	 */
	public HttpPage3 setCachingMethod(final CachingMethod method) {
		if (method != null) {
			this.caching.set(method);
		}
		return this;
	}

	/**
	 * Checks if a valid <code>CachingMethod</code> is present and is not set to
	 * <code>NO_CACHE</code> or <code>BLANK</code>.
	 * 
	 * @returns boolean
	 */
	public boolean isCaching() {
		return this.caching.get() != null && this.caching.get() != CachingMethod.NO_CACHE && this.caching.get() != CachingMethod.BLANK;
	}

	public static class Request implements AutoCloseable {
		private final InputStream is;
		private final Map<String, String> headers;
		private final String url;
		private final String ip;
		private final AtomicReference<RequestType> type = new AtomicReference<RequestType>(RequestType.GET);
		private final RequestProtocolType protocol;
		private final Socket socket;

		@Deprecated
		public Request(final String url, final String ip) {
			this(url, ip, RequestProtocolType.UNKNOWN);
		}

		public Request(final String url, final String ip, final RequestProtocolType protocol) {
			this(url, ip, protocol, null);
		}

		public Request(final String url, final String ip, final RequestProtocolType protocol, final Socket socket) {
			this.is = null;
			this.url = Variables.requireNonNullObject(url, "url");
			this.headers = FastMaps.map();
			this.ip = ip;
			this.protocol = Variables.requireNonNullObject(protocol, "protocol");
			this.socket = socket;
		}

		@Deprecated
		public Request(final String url, final InputStream is, final String ip) {
			this(url, is, ip, RequestProtocolType.UNKNOWN);
		}

		public Request(final String url, final InputStream is, final String ip, final RequestProtocolType protocol) {
			this(url, is, ip, protocol, null);
		}

		public Request(final String url, final InputStream is, final String ip, final RequestProtocolType protocol, final Socket socket) {
			this.url = Variables.requireNonNullObject(url, "url");
			this.is = Variables.requireNonNullObject(is, "is");
			this.headers = FastMaps.map();
			this.ip = ip;
			this.protocol = Variables.requireNonNullObject(protocol, "protocol");
			this.socket = socket;
		}

		/**
		 * Checks for the following:
		 * <ul>
		 * <li>If an <code>InputStream</code> is present</li>
		 * <li>The <code>InputStream</code> is NOT a blank stream</li>
		 * <li>The <code>Transfer-Encoding</code> OR <code>Content-Length</code> header
		 * is present.
		 * </ul>
		 * 
		 * @returns boolean
		 */
		public boolean hasInputStream() {
			return this.is != null && this.is != BLANK_IO_STREAM
					&& (this.headers.containsKey("Transfer-Encoding") || this.headers.containsKey("Content-Length"));
		}

		/**
		 * Returns the URL requested by the user.
		 * 
		 * @returns String
		 */
		public String url() {
			return this.url;
		}

		/**
		 * Converts the requested URL into a <code>Map&lt;String,String&gt;</code> with
		 * the query parameters.
		 * 
		 * @returns Map&lt;String,String&gt;
		 */
		public Map<String, String> urlAsQueryList() {
			return URLQueryParser.extract(this.url);
		}

		/**
		 * Returns the IP used for the request.
		 * 
		 * @returns String
		 */
		public String ip() {
			return this.ip;
		}

		/**
		 * Returns the <code>InputStream</code> if available, else returns null.
		 * 
		 * @returns InputStream
		 */
		public InputStream getInputStream() {
			return this.is;
		}

		/**
		 * Checks if there is an <code>InputStream</code> and is a non-<code>GET</code>
		 * request.
		 * 
		 * @returns boolean
		 */
		public boolean hasData() {
			return hasInputStream() && !isType(RequestType.GET);
		}

		/**
		 * Returns the request headers.
		 * 
		 * @returns Map&lt;String, String&gt;
		 */
		public Map<String, String> getHeaders() {
			return this.headers;
		}

		/**
		 * This is an internal function and shouldn't be used. <br>
		 * Imports headers from a provided <code>Map&lt;String,String&gt;</code>.
		 * 
		 * @param headers - Map&lt;String, String&gt;
		 * @returns Request
		 */
		public Request importHeaders(final Map<String, String> headers) {
			this.headers.putAll(Variables.requireNonNullObject(headers, "headers"));
			return this;
		}

		/**
		 * This is an internal function and shouldn't be used. <br>
		 * Sets the <code>RequestType</code> for the <code>Request</code>.
		 * 
		 * @param type - RequestType
		 * @returns Request
		 */
		public Request setRequestType(final RequestType type) {
			this.type.set(Variables.requireNonNullObject(type, "type"));
			return this;
		}

		/**
		 * Returns the <code>RequestType</code> for the request.
		 * 
		 * @returns RequestType
		 */
		public RequestType getType() {
			return this.type.get();
		}

		/**
		 * Checks if the provided <code>RequestType</code> is the same as the request's
		 * <code>RequestType</code>.
		 * 
		 * @param type - RequestType
		 * @returns boolean
		 */
		public boolean isType(final RequestType type) {
			return this.type.get().equals(type);
		}

		/**
		 * Returns the request headers as an array of <code>Entry</code>.
		 * 
		 * @returns Entry[]
		 */
		public Entry[] headersAsEntries() {
			final Entry[] e = new Entry[this.headers.size()];
			int i = 0;
			for (final Map.Entry<String, String> s : this.headers.entrySet()) {
				e[i] = new Entry(s.getKey(), s.getValue());
				i += 1;
			}
			return e;
		}

		/**
		 * Returns the request headers as an array of <code>Entry</code>, excluding any
		 * headers within the exclusion array.
		 * 
		 * @param exclude - String[]
		 * @returns Entry[]
		 */
		public Entry[] headersAsEntries(final String... exclude) {
			final Entry[] e = new Entry[this.headers.size()];
			int i = 0;
			for (final Map.Entry<String, String> s : this.headers.entrySet()) {
				if (ArrayUtils.StringArrayUtils.containsInStringOrStartsWith(exclude, s.getKey())) {
					continue;
				}
				e[i] = new Entry(s.getKey(), s.getValue());
				i += 1;
			}
			return e;
		}

		/**
		 * Returns the header's value if present.
		 * 
		 * @param key - String
		 * @returns String
		 */
		public String header(final String key) {
			return this.getHeaders().get(key);
		}

		/**
		 * Returns the header's value if present, else returns the fallback string.
		 * 
		 * @param key      - String
		 * @param fallback - String
		 * @returns String
		 */
		public String header(final String key, final String fallback) {
			try {
				final String x = this.getHeaders().get(key);
				if (x == null) {
					return fallback;
				} else {
					return x;
				}
			} catch (Exception e) {
				return fallback;
			}
		}

		@Override
		public void close() throws IOException {
			this.headers.clear();
			if (this.is != null)
				this.is.close();
		}

		/**
		 * Checks if a request header has been set.
		 * 
		 * @param string - String
		 * @returns boolean
		 */
		public boolean hasHeader(final String string) {
			if (string == null || string.isEmpty())
				return false;
			return this.headers.containsKey(string);
		}

		/**
		 * Checks if a request header is present and has the same value.
		 * 
		 * @param key   - String
		 * @param value - String
		 * @returns boolean
		 */
		public boolean headerIs(final String key, final String value) {
			if ((key == null || key.isEmpty()) || (value == null || value.isEmpty()))
				return false;
			if (!this.headers.containsKey(key))
				return false;
			return this.headers.get(key).equals(value);
		}

		/**
		 * Returns the protocol scheme used for this request.
		 * 
		 * @returns String
		 * @since 3.0.5
		 */
		public String protocolScheme() {
			return this.protocol.scheme();
		}

		/**
		 * Returns the protocol definition used for this request.
		 * 
		 * @returns String
		 * @since 3.0.5
		 */
		public String protocolDefinition() {
			return this.protocol.protocolDef();
		}

		/**
		 * Returns the protocol used for this request.
		 * 
		 * @returns RequestProtocolType
		 * @since 3.0.5
		 */
		public RequestProtocolType protocol() {
			return this.protocol;
		}

		/* Socket debugging features */

		/**
		 * Returns the receive buffer in the client's Socket.
		 * 
		 * @returns Integer (or -1 if unavailable)
		 * @since 3.0.5
		 */
		public int socket_rcvbuf() {
			if (this.socket == null)
				return -1;
			return HttpServerUtil.__sock_SO_RCVBUF(this.socket);
		}

		/**
		 * Returns the send buffer in the client's Socket.
		 * 
		 * @returns Integer (or -1 if unavailable)
		 * @since 3.0.5
		 */
		public int socket_sndbuf() {
			if (this.socket == null)
				return -1;
			return HttpServerUtil.__sock_SO_SNDBUF(this.socket);
		}

		/**
		 * Returns the TLS version used in "TLSv1.x" format.
		 * 
		 * @returns String (or null if unavailable)
		 * @since 3.0.5
		 */
		public String socket_tlsVersion() {
			if (this.socket == null)
				return null;
			if (!(this.socket instanceof SSLSocket))
				return null;
			return ((SSLSocket) this.socket).getSession().getProtocol();
		}

		/**
		 * Returns the TLS cipher used.
		 * 
		 * @returns String (or null if unavailable)
		 * @since 3.0.5
		 */
		public String socket_tlsCipher() {
			if (this.socket == null)
				return null;
			if (!(this.socket instanceof SSLSocket))
				return null;
			return ((SSLSocket) this.socket).getSession().getCipherSuite();
		}

		/**
		 * &lt;&lt;UNDOCUMENTED&gt;&gt;
		 * 
		 * @returns byte[] (or null if unavailable)
		 * @since N/A
		 */
		public byte[] socket_tlsHash() {
			if (this.socket == null)
				return null;
			if (!(this.socket instanceof SSLSocket))
				return null;
			try {
				return ((SSLSocket) this.socket).getSession().getLocalCertificates()[0].getEncoded();
			} catch (CertificateEncodingException e) {
				throw new RuntimeException(e);
			}
		}

		public static Request dummy() {
			return new Request("DUMMY", BLANK_IO_STREAM, "0.0.0.0", RequestProtocolType.HTTP_1_0, new DummySocket(BLANK_IO_STREAM, null));
		}
	}

	public abstract Response onRequest(final Request request);

	/**
	 * Returns a blank InputStream which always reads <code>-1</code>.
	 * 
	 * @returns InputStream
	 */
	public InputStream blankStream() {
		return BLANK_IO_STREAM;
	}

	/**
	 * Returns a blank Copyable which performs no operations.
	 * 
	 * @returns Copyable
	 */
	public Copyable blankCopyable() {
		return NOOP_COPYABLE;
	}

	/**
	 * Returns a blank NIOCopyable which performs no operations.
	 * 
	 * @returns NIOCopyable
	 */
	public NIOCopyable blankNIOCopyable() {
		return NOOP_NIO_COPYABLE;
	}

	/**
	 * Returns a blank response with HTTP error code 503 (Internal server error)
	 * 
	 * @returns Response
	 */
	@SuppressWarnings("resource")
	public Response fail() {
		return new Response(503, blankStream()).requestKeepAliveDisable(true).header("Retry-After", "0").header("Accept-Ranges", "bytes");
	}

	/**
	 * Returns a blank Response with a specific HTTP code.
	 * 
	 * @param code - int
	 * @returns Response
	 */
	@SuppressWarnings("resource")
	public Response fail(final int code) {
		return new Response(code, blankStream()).requestKeepAliveDisable(true).header("Retry-After", "0").header("Accept-Ranges", "bytes");
	}

	/**
	 * Creates a ByteArrayInputStream with a provided string as the contents.
	 * 
	 * @param text - String
	 * @returns InputStream
	 */
	public InputStream createStream(final String text) {
		if (text == null || StringsUtil.isBlank(text))
			return blankStream();
		return new ByteArrayInputStream(OS.isWindows() ? text.getBytes(CharsetUtil.UTF_8) : text.getBytes());
	}

	/**
	 * Creates a ByteArrayInputStream with a provided byte array as the contents.
	 * 
	 * @param data - byte[]
	 * @returns InputStream
	 */
	public InputStream createStream(final byte[] data) {
		if (data == null || data.length == 0)
			return blankStream();
		return new ByteArrayInputStream(data);
	}

	/**
	 * Reads an InputStream fully and returns it's contents as a String
	 * 
	 * @param is - InputStream
	 * @returns String or null when <code>is</code> is null
	 */
	public String toString(final InputStream is) {
		if (is == null)
			return null;
		try {
			return new String(IOUtils.toByteArray(is));
		} catch (IOException e) {
			throw new RuntimeException(e);
		}
	}

	/**
	 * Checks if the page is marked for exclusion from nCaptcha, if enabled.
	 * 
	 * @returns boolean
	 */
	public boolean isExcludedFromCaptcha() {
		return this.PAGE_EXCLUDE_FROM_CAPTCHA.get();
	}

	/**
	 * Sets the captcha exclusion flag for nCaptcha, if enabled.
	 * 
	 * @param value - boolean
	 * @returns Current Instance of HttpPage3
	 */
	public HttpPage3 setCaptchaExclusion(final boolean value) {
		this.PAGE_EXCLUDE_FROM_CAPTCHA.lazySet(value);
		return this;
	}

	/**
	 * Returns if a JSON object should be returned instead of nCaptcha's page for
	 * users. <br>
	 * Intended to tell the user that they have to fill out a captcha.
	 * 
	 * @returns boolean
	 */
	public boolean shouldUseJSONCaptchaPage() {
		return this.USE_JSON_CAPTCHA_RESPONSE.get();
	}

	/**
	 * Sets the JSON response page flag for nCaptcha.
	 * 
	 * @param value - boolean
	 * @returns Current Instance of HttpPage3
	 */
	public HttpPage3 setUseJSONCaptchaPage(final boolean value) {
		this.USE_JSON_CAPTCHA_RESPONSE.lazySet(value);
		return this;
	}
}
